page.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import { useParams, useRouter } from "next/navigation";
  4. import { MessageList } from "@/components/dashboard/MessageList";
  5. import { MessageInput } from "@/components/dashboard/MessageInput";
  6. import { ChatHeader } from "@/components/dashboard/ChatHeader";
  7. import { Button } from "@/components/ui/button";
  8. import { useAuth } from "@/features/agent/hooks/useAuth";
  9. import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
  10. import {
  11. fetchMessages,
  12. sendMessage as sendMessageApi,
  13. markMessagesRead,
  14. } from "@/features/agent/services/messageApi";
  15. import { fetchConversationDetail } from "@/features/agent/services/conversationApi";
  16. import {
  17. ConversationDetail,
  18. ConversationSummary,
  19. MessageItem,
  20. MessagesReadPayload,
  21. ChatWebSocketPayload,
  22. } from "@/features/agent/types";
  23. import type { WSMessage } from "@/lib/websocket";
  24. export default function AgentChatPage() {
  25. const params = useParams();
  26. const router = useRouter();
  27. const { agent, loading: authLoading } = useAuth();
  28. const conversationId = useMemo(() => {
  29. const id = params?.conversationId;
  30. if (!id) {
  31. return null;
  32. }
  33. const parsed = Number.parseInt(String(id), 10);
  34. return Number.isNaN(parsed) ? null : parsed;
  35. }, [params]);
  36. const [messages, setMessages] = useState<MessageItem[]>([]);
  37. const [conversationDetail, setConversationDetail] =
  38. useState<ConversationDetail | null>(null);
  39. const [conversationSummary, setConversationSummary] =
  40. useState<ConversationSummary | null>(null);
  41. const [messageInput, setMessageInput] = useState("");
  42. const [loadingMessages, setLoadingMessages] = useState(true);
  43. const [sending, setSending] = useState(false);
  44. const [highlightKeyword, setHighlightKeyword] = useState("");
  45. const handleMarkMessagesRead = useCallback(
  46. async (
  47. conversationIdParam?: number,
  48. readerIsAgentParam?: boolean
  49. ) => {
  50. const targetConversationId = conversationIdParam ?? conversationId;
  51. const targetReaderIsAgent = readerIsAgentParam ?? true;
  52. if (!targetConversationId) {
  53. return;
  54. }
  55. const result = await markMessagesRead(
  56. targetConversationId,
  57. targetReaderIsAgent
  58. );
  59. if (!result || result.message_ids.length === 0) {
  60. return;
  61. }
  62. const idSet = new Set(result.message_ids);
  63. setMessages((prev) =>
  64. prev.map((msg) =>
  65. idSet.has(msg.id)
  66. ? {
  67. ...msg,
  68. is_read: true,
  69. read_at: result.read_at ?? msg.read_at ?? null,
  70. }
  71. : msg
  72. )
  73. );
  74. if (targetReaderIsAgent) {
  75. setConversationDetail((prev) =>
  76. prev ? { ...prev, unread_count: result.unread_count } : prev
  77. );
  78. setConversationSummary((prev) =>
  79. prev ? { ...prev, unread_count: result.unread_count } : prev
  80. );
  81. } else {
  82. setConversationDetail((prev) =>
  83. prev
  84. ? {
  85. ...prev,
  86. last_seen_at: result.read_at ?? prev.last_seen_at ?? null,
  87. }
  88. : prev
  89. );
  90. }
  91. },
  92. [conversationId]
  93. );
  94. const loadConversationDetail = useCallback(async () => {
  95. if (!conversationId) {
  96. return;
  97. }
  98. const detail = await fetchConversationDetail(conversationId);
  99. if (detail) {
  100. setConversationDetail(detail);
  101. setConversationSummary({
  102. id: detail.id,
  103. visitor_id: detail.visitor_id,
  104. agent_id: detail.agent_id,
  105. status: detail.status,
  106. created_at: detail.created_at,
  107. updated_at: detail.updated_at,
  108. last_message: detail.last_message,
  109. unread_count: detail.unread_count ?? 0,
  110. });
  111. }
  112. }, [conversationId]);
  113. const loadMessages = useCallback(async () => {
  114. if (!conversationId) {
  115. return;
  116. }
  117. setLoadingMessages(true);
  118. try {
  119. const data = await fetchMessages(conversationId);
  120. setMessages(data);
  121. // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
  122. } catch (error) {
  123. console.error("拉取消息失败:", error);
  124. } finally {
  125. setLoadingMessages(false);
  126. }
  127. }, [conversationId]);
  128. useEffect(() => {
  129. if (!conversationId) {
  130. router.push("/agent/dashboard");
  131. }
  132. }, [conversationId, router]);
  133. useEffect(() => {
  134. if (!conversationId || !agent) {
  135. return;
  136. }
  137. loadConversationDetail();
  138. loadMessages();
  139. }, [conversationId, agent, loadConversationDetail, loadMessages]);
  140. const handleSendMessage = useCallback(async () => {
  141. if (!conversationId || !agent?.id || !messageInput.trim() || sending) {
  142. return;
  143. }
  144. setSending(true);
  145. try {
  146. await sendMessageApi({
  147. conversationId,
  148. content: messageInput,
  149. senderId: agent.id,
  150. });
  151. setMessageInput("");
  152. } catch (error) {
  153. console.error(error);
  154. alert((error as Error).message);
  155. } finally {
  156. setSending(false);
  157. }
  158. }, [agent?.id, conversationId, messageInput, sending]);
  159. const handleNewMessage = useCallback(
  160. (message: MessageItem) => {
  161. setMessages((prev) => {
  162. const exists = prev.some((item) => item.id === message.id);
  163. if (exists) {
  164. return prev;
  165. }
  166. return [...prev, message];
  167. });
  168. setConversationDetail((prev) => {
  169. if (!prev) {
  170. return prev;
  171. }
  172. const nextUnread =
  173. !message.sender_is_agent && message.message_type !== "system_message"
  174. ? (prev.unread_count ?? 0) + 1
  175. : prev.unread_count ?? 0;
  176. return {
  177. ...prev,
  178. updated_at: message.created_at,
  179. unread_count:
  180. message.sender_is_agent || message.message_type === "system_message"
  181. ? prev.unread_count ?? 0
  182. : nextUnread,
  183. last_message: {
  184. id: message.id,
  185. content: message.content,
  186. sender_is_agent: message.sender_is_agent,
  187. message_type: message.message_type ?? "user_message",
  188. is_read: Boolean(message.is_read),
  189. read_at: message.read_at ?? null,
  190. created_at: message.created_at,
  191. },
  192. };
  193. });
  194. setConversationSummary((prev) => {
  195. if (!prev) {
  196. return prev;
  197. }
  198. const nextUnread =
  199. !message.sender_is_agent && message.message_type !== "system_message"
  200. ? (prev.unread_count ?? 0) + 1
  201. : prev.unread_count ?? 0;
  202. return {
  203. ...prev,
  204. updated_at: message.created_at,
  205. unread_count:
  206. message.sender_is_agent || message.message_type === "system_message"
  207. ? prev.unread_count ?? 0
  208. : nextUnread,
  209. last_message: {
  210. id: message.id,
  211. content: message.content,
  212. sender_is_agent: message.sender_is_agent,
  213. message_type: message.message_type ?? "user_message",
  214. is_read: Boolean(message.is_read),
  215. read_at: message.read_at ?? null,
  216. created_at: message.created_at,
  217. },
  218. };
  219. });
  220. // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
  221. if (message.conversation_id === conversationId) {
  222. loadConversationDetail();
  223. }
  224. },
  225. [conversationId, loadConversationDetail]
  226. );
  227. const handleMessagesReadEvent = useCallback(
  228. (payload: MessagesReadPayload) => {
  229. if (!conversationId) {
  230. return;
  231. }
  232. // 检查对话ID是否匹配
  233. const payloadConversationId = payload?.conversation_id;
  234. if (payloadConversationId && payloadConversationId !== conversationId) {
  235. return;
  236. }
  237. const ids = Array.isArray(payload?.message_ids)
  238. ? payload.message_ids
  239. : [];
  240. if (ids.length === 0) {
  241. return;
  242. }
  243. const readAt = payload?.read_at;
  244. const readerIsAgent = Boolean(payload?.reader_is_agent);
  245. const unreadCount =
  246. typeof payload?.unread_count === "number"
  247. ? payload.unread_count
  248. : undefined;
  249. // 对于客服端:只有当 reader_is_agent === false 时(访客读取了客服的消息),
  250. // 才更新客服消息(sender_is_agent === true)的已读状态
  251. if (readerIsAgent) {
  252. // 客服读取了访客的消息,更新未读数(不更新消息的已读状态,因为这是客服读取的)
  253. if (unreadCount !== undefined) {
  254. setConversationDetail((prev) =>
  255. prev ? { ...prev, unread_count: unreadCount } : prev
  256. );
  257. setConversationSummary((prev) =>
  258. prev ? { ...prev, unread_count: unreadCount } : prev
  259. );
  260. }
  261. return;
  262. }
  263. // 访客读取了客服的消息,更新客服消息的已读状态
  264. const messageIdSet = new Set(ids);
  265. setMessages((prev) =>
  266. prev.map((msg) =>
  267. // 只更新客服自己的消息(sender_is_agent === true)的已读状态
  268. messageIdSet.has(msg.id) && msg.sender_is_agent
  269. ? {
  270. ...msg,
  271. is_read: true,
  272. read_at: readAt ?? msg.read_at ?? null,
  273. }
  274. : msg
  275. )
  276. );
  277. // 更新访客的最后活跃时间
  278. setConversationDetail((prev) =>
  279. prev
  280. ? { ...prev, last_seen_at: readAt ?? prev.last_seen_at ?? null }
  281. : prev
  282. );
  283. },
  284. [conversationId]
  285. );
  286. const handleWebSocketMessage = useCallback(
  287. (event: WSMessage<ChatWebSocketPayload>) => {
  288. if (!event) {
  289. return;
  290. }
  291. if (event.type === "new_message" && event.data) {
  292. const data = event.data as MessageItem;
  293. if (typeof data.conversation_id === "number") {
  294. handleNewMessage(data);
  295. }
  296. } else if (event.type === "messages_read") {
  297. // 确保处理已读事件,并传入对话ID
  298. const payload = event.data as MessagesReadPayload;
  299. if (!payload.conversation_id && event.conversation_id) {
  300. payload.conversation_id = event.conversation_id;
  301. }
  302. handleMessagesReadEvent(payload);
  303. }
  304. },
  305. [handleMessagesReadEvent, handleNewMessage]
  306. );
  307. useWebSocket<ChatWebSocketPayload>({
  308. conversationId,
  309. enabled: Boolean(conversationId),
  310. onMessage: handleWebSocketMessage,
  311. onError: (error) => {
  312. // 静默处理错误,避免影响用户体验
  313. },
  314. onClose: () => {
  315. // 静默处理关闭,避免影响用户体验
  316. },
  317. });
  318. const handleBack = useCallback(() => {
  319. router.push("/agent/dashboard");
  320. }, [router]);
  321. const unreadCount =
  322. conversationDetail?.unread_count ?? conversationSummary?.unread_count ?? 0;
  323. if (authLoading || !agent) {
  324. return (
  325. <div className="flex justify-center items-center min-h-screen bg-gray-50 text-gray-600">
  326. 加载中...
  327. </div>
  328. );
  329. }
  330. if (!conversationId) {
  331. return null;
  332. }
  333. return (
  334. <div className="flex flex-col h-screen bg-gray-50">
  335. <div className="bg-white border-b border-gray-200">
  336. <div className="flex items-center gap-4 px-4 h-16">
  337. <Button
  338. onClick={handleBack}
  339. variant="outline"
  340. size="sm"
  341. >
  342. ← 返回
  343. </Button>
  344. <div className="flex-1">
  345. <ChatHeader
  346. conversationId={conversationId}
  347. lastSeenAt={conversationDetail?.last_seen_at}
  348. unreadCount={unreadCount}
  349. onMarkAllRead={() => handleMarkMessagesRead(conversationId, true)}
  350. onRefresh={() => {
  351. loadMessages();
  352. loadConversationDetail();
  353. }}
  354. />
  355. </div>
  356. </div>
  357. </div>
  358. <div className="flex-1 flex flex-col bg-white">
  359. <MessageList
  360. messages={messages}
  361. loading={loadingMessages}
  362. highlightKeyword={highlightKeyword}
  363. onHighlightClear={() => setHighlightKeyword("")}
  364. currentUserIsAgent={true}
  365. conversationId={conversationId}
  366. onMarkMessagesRead={handleMarkMessagesRead}
  367. />
  368. <MessageInput
  369. value={messageInput}
  370. onChange={setMessageInput}
  371. onSubmit={handleSendMessage}
  372. sending={sending}
  373. />
  374. </div>
  375. </div>
  376. );
  377. }